Skip to content

fix(captions): place captions by anchor and margin, not by an invisible band (rc.3) - #484

Merged
EtienneLescot merged 8 commits into
release/v1.10.0from
claude/captions-anchor-model-rc3
Aug 22, 2026
Merged

fix(captions): place captions by anchor and margin, not by an invisible band (rc.3)#484
EtienneLescot merged 8 commits into
release/v1.10.0from
claude/captions-anchor-model-rc3

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Cherry-pick of #482 onto release/v1.10.0, for rc.3.

Important

Merge #482 to main first. This branch carries the same commits, but they are not on main yet — merging here first would put the release branch ahead of main, which inverts the cherry-pick contract in technical-documentation/engineering/release-and-secrets.md.

Why this is a fix, not a feature

rc.2 shipped the caption placement UI from #471, and it was rejected on sight: the width slider changed nothing visible, the horizontal position appeared to do nothing, it collided with text-align, changing width moved the caption vertically, and the readout could say -7.3%.

All five are one decision: every caption was drawn inside an invisible fixed-height box (22% of frame) with the ink centred in it, while the only visible thing — the background plate — hugs the text. Every control moved that invisible box; none controlled the visible block. This replaces the decision rather than the controls.

What changes

One control per axis, each naming the edge it measures from:

anchorV + insetY   bottom | top,           and a distance from that edge
anchorH + insetX   left | center | right,  and a distance from that edge

Invariant, asserted as a property test: bottom anchor → the drawn block's bottom edge is at 100 − insetY; top anchor → its top edge is at insetY. For every font size, background state, word count, wrap outcome and resolution.

Deleted: verticalPosition, offsetY, offsetX, width, textAlign, and the machinery that only existed to compensate for the old geometry. width becomes a derived column (BBC line-length table).

Native contract: one optional field, verticalAlign, modelled exactly on space from #396Option<String>, #[serde(default)], key omitted when absent, so annotations render byte for byte as before. Export stays iso-preview: one render path feeds both, and captions stay fully native.

Perf: the per-cue box goes 22% → 20% of frame height, so the texture gets marginally smaller. Same texture count, same draws, same shader.

Migration

Reproduces the pixels, not the fields: the old band's geometry is known, so the drawn block's edges are recoverable and the nearer one becomes the anchor. A migrated project does not move on screen.

One deliberate exception: line breaks change for a project saved with a non-default width, because that WAS the wrap column. Unavoidable, and worth a changelog line.

Differences from #482, and why

Test plan

Known limits

  • The default insets (1.5% vertical, 10% horizontal) were chosen by eye on a 16:9 export. Vertical keeps a larger 12.5% inset so captions clear the chrome TikTok/Reels/Shorts draw over the bottom of a 9:16 video — nobody has eyeballed that case.
  • The last commits of fix(captions): place captions by anchor and margin, not by an invisible band #482 were not reviewed by CodeRabbit (quota), though CI is green on all of them.

🤖 Generated with Claude Code

…ts block

Adds one optional field to the text payload, `verticalAlign`, threaded through
the scene into all three rasterizers. Absent means centred — so annotations,
which never emit it, render byte for byte as before.

Centring is what made caption placement incoherent: a centred block moves BOTH
its edges when it grows, so a caption drifted vertically whenever the text
wrapped to another line, and no setting could hold it still. An anchored block
keeps its anchored edge exactly where it was put, at any line count. The Linux
test asserts precisely that, which is the assertion the old geometry could not
express.

Option<String> and not an enum, for the same reason as `space`: serde rejects an
unknown unit variant, so a future value would cost the whole scene on an older
binary rather than one misplaced caption.

Windows needed the layout box inset vertically by the plate margin and the draw
origin offset to match, or a bottom anchor puts the glyphs flush against the box
and the plate's lower margin gets clipped. That arithmetic cancels exactly for
the centred case; it is now a pure function with a test pinning it to where it
was, because it was the one calculation on the Windows path no test covered.

Nothing emits the field yet.

(cherry picked from commit c1fb929)
…le band

Replaces the whole placement model. Every control now names the edge it measures
from, and there is exactly one per axis:

  anchorV + insetY   bottom | top, and a distance from that edge
  anchorH + insetX   left | center | right, and a distance from that edge

Deleted: verticalPosition, offsetY, offsetX, width, textAlign — and the
machinery that existed only to compensate for the old geometry (the fixed 22%
band, the ink-height estimate, the overhang, the reachable-offset range, the
preset-vs-slider epsilon).

The old model drew every caption inside an invisible fixed-height box and let
each rasterizer centre the ink in it, while the only thing on screen — the
background plate — hugs the text. So `width` changed nothing visible until the
text happened to wrap; the horizontal offset moved a band the text floated
inside; text-align fought that offset for the same outcome; wrapping grew a
centred block from both edges, which moved the caption vertically when nothing
vertical had been touched; and the vertical offset had to be signed and clamped
against an estimate, which is where "-7.3%" came from. All five are the same
decision, so this replaces the decision rather than the controls.

`width` becomes a derived column instead of a control (BBC's line-length table:
68% landscape, 90% vertical). How much text is on screen is already a legible
question elsewhere — min/max words per line. The default inset follows the
output aspect, because 5% on a 9:16 export is under the platform's own chrome.

Migration reproduces the PIXELS, not the fields: the old band's geometry is
known, so the drawn block's edges are recoverable, and the nearer one becomes
the anchor. A migrated project does not move on screen. Line breaks do change
for a project with a non-default width, since that WAS the wrap column.

Tests assert the invariant as a property — the anchored edge lands at 100−insetY
(or insetY) for every font size, background state and inset — rather than
pinning numbers a future change would just have to update.

(cherry picked from commit b4810d1)
The Linux anchor test measured the bottom-most inked row and called a 184→199
move a drift. It is not: "Hx" has no descenders and "replier" does, so the ink
reaches further down inside an identically-placed line box. What this code pins
is the line box — and therefore the plate, which is what the compositor draws
and what the viewer sees. Anchoring the box rather than the last glyph pixel is
the behaviour every text renderer has.

So the assertion moves to `atlas.plate`, which is deterministic and is the
actual contract. The ink is still checked, but only for the thing that is true
of it: that it stays inside the plate carrying it, with the same `pad_y`
tolerance `the_plate_hugs_the_text_instead_of_filling_the_box` already uses for
that relationship — a glyph may overshoot its own line box slightly, which is
precisely the assumption the first version got wrong.

Found by CI: only text_windows.rs compiles on the machine this was written on.

(cherry picked from commit b061746)
…igrate insetX

Two findings from CodeRabbit's review of #482, both real.

**The plate lost its margin on the anchored side.** Pinning the TEXT block flush
against the box edge left the plate laying all of its padding on the opposite
side and none on the anchored one: at the default bottom anchor the background
hugged the glyphs' baseline exactly while breathing twice as much above them.
What should touch the box edge is the plate — that is what the viewer sees, and
what the anchor invariant is stated in terms of. Reserving `pad_y` on the
anchored side puts the plate's edge in the same place and makes its padding
symmetric again.

Conditioned on a plate actually being drawn, on all three backends. Windows was
already inset but did it unconditionally, which would have placed its glyphs
`pad_y` away from Linux and macOS whenever the background was off; it is now
conditional too, so the three agree in both states.

**The migration snapped left/right captions to the frame edge.** It returned
`insetX: 0` for every document, so a migrated caption whose band sat at 5% moved
to 0%. The vertical half already reconstructed the drawn edge exactly; the
horizontal half now does the same from the values it had already computed —
which is what "reproduce the pixels, not the fields" was supposed to mean.

(cherry picked from commit 18f4d09)
rustc rejects `///` on a parameter; it has to be a plain `//`. Only
text_macos.rs compiles on macOS, so the machine this was written on could not
see it — and rustfmt parses the file happily, so a local syntax gate would not
have caught it either. CI did.

(cherry picked from commit 2cf1b22)
`patchCaptionSettings` read the document without an aspect, so the write that
MATERIALISES the defaults into a project that never had caption settings always
wrote the landscape ones. A 9:16 export got `insetY: 5` frozen in, and the
stored value then won for good — putting the caption under the platform's own
chrome, which is the exact failure the aspect-derived default exists to prevent.

`useCaptions` now resolves the aspect once (through `resolveAspectRatioValue`,
the same resolver the preview and the scene description use, so all three agree
on what a legacy "native" selection means) and hands it to the read and to every
write: `set`, `setLive` and the language reset in `deleteTranslation`.

Found by CodeRabbit on #482.

(cherry picked from commit 096a551)
The anchor redesign's doc rewrite rode along with the guide-overlay commit,
which is add-then-revert within the PR and was therefore skipped here. This
carries the surviving half: the settings table, and the sections that described
the fixed band, the overhang and the preset machinery — none of which exists any
more.

Applied as one commit rather than cherry-picked because this branch's copy of the
file never received #471's docs commit (docs are excluded from the cherry-pick
lane), so its lineage differs from main's and the patch does not apply.
… padding

1.5% from the anchored edge, 10% from the horizontal one. Picked by eye against
the editor's default padding rather than from a broadcast spec: the footage sits
inset inside the frame, so what reads as "just off the edge" is a much smaller
number than the 5% BBC states for a full-bleed broadcast frame.

Landscape only — those two values were eyeballed on a 16:9 export. Vertical
keeps its 12.5%, which answers a different question: TikTok, Reels and Shorts
draw their own chrome over the bottom eighth of a 9:16 video, so 1.5% there
would put the caption behind a UI. Nobody has looked at that case, so it stays
on the conservative value.

(cherry picked from commit 7dcd9f0)
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3aa72fb5-b53f-44c3-bb7e-18c286e3806a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtienneLescot
EtienneLescot merged commit b71311e into release/v1.10.0 Aug 22, 2026
17 checks passed
@EtienneLescot
EtienneLescot deleted the claude/captions-anchor-model-rc3 branch August 22, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant